<?php

namespace StudyBuddy;

/**
 * Wrapper for curl functions
 *
 */
class Curl extends StudyBuddyObject {

    /**
     * Headers returned from the http server
     *
     * @var array where keys are header name, values are values
     */
    protected $aHeaders = array();

    /**
     * Array of headers to send with request
     * Not to be confused with aHeaders which
     * is array of headers the server sends us
     * back in response
     *
     * @var array
     */
    protected $aRequestHeaders = array();

    /**
     * Body of response (usually some html or xml)
     * as returned from the http server
     * @var srting
     */
    protected $body;

    /**
     * Array or response http headers
     * @var array
     */
    protected $aResponseHeaders = array();

    /**
     * Array returned by curl_getinfo()
     * @var array
     */
    protected $info;

    /**
     * Response code as returned by the http server
     *
     * @var string (numeric string)
     */
    protected $httpResponseCode;

    /**
     *
     * Curl handle object
     * @var object curl handle
     */
    protected $request;

    /**
     * Full path to file which will
     * be used to store cookies that curl
     * gets back from request url
     * curl can then reuse these cookies in subsequent requests
     *
     *
     * @var string must be a full path to writable file
     */
    protected $cookieFile;

    /**
     * Referrer url
     * will be used in Request
     *
     * @var string
     */
    protected $referrer = '';

    /**
     * Url of request
     *
     * @var string
     */
    protected $url;

    /**
     * Set the file that will be used
     * for cookies storage
     *
     * @param string $file must be a full path
     * if file does not exist php will attempt to create it
     * and if that fails that DevException is thrown
     *
     * @throws DevException
     */
    public function setCookieFile($file) {
        if (is_writable($file)) {
            $this->cookieFile = $file;
        } else {
            if (false === $r = @fopen($file, 'w')) {
                throw new DevException('Unable to create cookie file: ' . $file);
            }

            $this->cookieFile = $file;
            \fclose($r);
        }

        return $this;
    }

    /**
     * Sets the value for the referrer
     *
     * @param string $url should be a valid url,
     * ideall it will be the url from which the request
     * would come in real life. For example when posting
     * to a login form it's best to set referret to
     * the page from where user would normally login
     *
     * When posting to a "send message" form it's best to
     * set the referrer to the page from which user normally
     * sends a message
     *
     * @return object $this
     */
    public function setReferrer($url) {
        $this->referrer = $url;

        return $this;
    }

    /**
     * Set value of useragent
     * This should be called BEFORE the call to
     * getDocument()
     *
     *
     * @param unknown_type $agent
     * @throws DevException if $agent is not a string
     *
     * @return object $this
     *
     */
    public function setUseragent($agent) {
        if (!is_string($agent)) {
            throw new DevException('Param $agent must be a string. Was: ' . gettype($agent));
        }

        $this->aOptions['useragent'] = $agent;

        return $this;
    }

    public function setBasicAuth($user, $pwd) {
        $this->aOptions['basicAuth'] = $user . ':' . $pwd;

        return $this;
    }

    /**
     * Default values for
     * timeout: 8 seconds
     * useragent: Mozilla
     *
     * These can be overritten in getDocument in third param
     * // used to also include this 'redirect' => 2,
     * but now cannot include redirect limit because
     * we handle redirects separately
     *
     * @var array
     */
    protected $aOptions = array('timeout' => 8,
        'useragent' => 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.1.4) Gecko/20091016 Firefox/3.5.4 GTB5');

    /**
     * Init the curl handler
     * if it has not yet been
     * created
     *
     * @reset bool if true then will reinitialize $this->request
     * even if it's already set.
     * 
     * @return object $this
     */
    public function initCurl($reset = false) {

        if ($reset || !isset($this->request) || !is_resource($this->request)) {
            $this->request = \curl_init();
        }

        return $this;
    }

    /**
     * Check if this version of curl has support
     * for SSL
     *
     * @return bool true if has SSL support | false if does not
     */
    protected function checkSSL() {
        $version = \curl_version();
        $supported = ($version['features'] & CURL_VERSION_SSL);

        return (bool) $supported;
    }

    /**
     * Main method to get (retreive) the document from url
     * as well as set the $this object with values
     * This method makes use of special cache-control
     * request headers: If-Modified-Since
     * and If-None-Match
     *
     * @param string $url URI from which we requesting the document
     *
     * @param string $since value to use for the If-Modified-Since
     * this should be in the rfs 2822 or 822 date/time format
     * Ideally (really strongly recommended) this value should be
     * previously received from the same http server from which
     * we are currently requesting the document. It is usually
     * important that the time is in the timezone of the server
     * from which we requesting the document
     *
     * @param string $etag value to be used for the
     * If-None-Match header. This should be the value previously
     * received from the same http server for the same document
     * that we currently requesting
     *
     * @param array $aOptions
     *
     * @return object $this
     *
     * @throws Exceptions are based on returned response code:
     * 304, 404 and timeout result in special corresponding exceptions
     *
     *
     */
    public function getDocument($url, $since = null, $etag = null, array $aOptions = array()) {

        if (!is_string($url)) {
            throw new DevException('value of param $url must be a string. was: ' . gettype($url));
        }

        if (null !== $since && !is_string($since)) {
            throw new DevException('value of param $since must be a string. was: ' . gettype($since));
        }

        if (null !== $etag && !is_string($etag)) {
            throw new DevException('value of param $etag must be a string. was: ' . gettype($etag));
        }


        if ('https' === \substr($url, 0, 5) && !$this->checkSSL()) {
            throw new \LogicException('Unable to make request to url: ' . $url . ' because your curl does not have support for  SSL protocol');
        }

        $this->url = $url;

        $aHeaders = array();
        $this->initCurl();


        /**
         * Quick and dirty fix to enable
         * SSL by just not verifying ssl certificates
         * Withot this like curl just would not work
         */
        \curl_setopt($this->request, CURLOPT_SSL_VERIFYPEER, false);

        if (false === \curl_setopt($this->request, CURLOPT_URL, $url)) {
            throw new \Exception('Unable to set url: ' . $url);
        }

        $this->setOptions($aOptions);

        if (null !== $since) {
            $aHeaders['If-Modified-Since'] = $since;
        }

        if (!empty($etag)) {
            $aHeaders['If-None-Match'] = $etag;
        }

        if (!empty($aHeaders)) {
            $this->setHeaders($aHeaders);
        }

        $response = \curl_exec($this->request);
        $this->info = \curl_getinfo($this->request);
        $error = \curl_error($this->request);
        $error_code = \curl_errno($this->request);
        $header_size = \curl_getinfo($this->request, CURLINFO_HEADER_SIZE);
        if (28 === (int) $error_code) {
            throw new HttpTimeoutException($error);
        }

        $this->httpResponseCode = \curl_getinfo($this->request, CURLINFO_HTTP_CODE);
        $this->body = \substr($response, $header_size);
        $header = \substr($response, 0, $header_size);
        $headers = \explode("\r\n", \str_replace("\r\n\r\n", '', $header));

        foreach ($headers as $h) {
            if (\preg_match('#(.*?)\:\s(.*)#', $h, $matches)) {
                $this->aResponseHeaders[strtolower($matches[1])] = \trim($matches[2]);
            }
        }

        $this->__destruct();

        return $this;
    }

    /**
     * Run this method after the getDocument()
     * It will examine the http response code and in case
     * it's not 200 or 201 it will throw
     * corresponding StudyBuddyException
     *
     * Calling this method after the getDocument() may be
     * convenient in case of parsing external XML or RSS feed
     * of some sort, or even parsing response from some API
     * as it takes case of all situations where we did not
     * get the "OK" response and even of situation where
     * the body of an otherwise "OK" respose was empty
     *
     * @throws sub-class of StudyBuddy\Exception, depending on
     * the Http response code. In case the response
     * code is 200 or 201 which is OK but there is no
     * content in the body, it throws \StudyBuddy\HttpEmptyBodyException
     *
     * @return object $this
     */
    public function checkResponse() {

        switch ($this->httpResponseCode) {

            case 200:
            case 201:
                if (!empty($this->body)) {

                    $this->__destruct();

                    return $this;
                }

                $ex = new HttpEmptyBodyException('Empty body');
                break;

            case 301:
            case 302:
            case 303:
            case 307:
                if ('' !== $newLocation = $this->getHeader('Location')) {
                    d(' redirect contains location: ' . $newLocation . ' $this->httpResponseCode: ' . $this->httpResponseCode);

                    $ex = new HttpRedirectException($newLocation, $this->httpResponseCode);
                } else {
                    $ex = new HttpResponseErrorException('Error ' . $this->httpResponseCode . ' message: ' . $this->getResponseStatus());
                }
                break;

            case 304:
                $ex = new Http304Exception('Content has not changed');
                break;

            case 401:
                $ex = new Http401Exception('Unauthorized login: ' . $this->url);
                break;

            case 404:
                $ex = new Http404Exception('page not found at this url: ' . $this->url);
                break;

            default:

                if ($this->httpResponseCode >= 400 && $this->httpResponseCode < 500) {
                    $ex = new Http400Exception('Error ' . $this->httpResponseCode, $this->httpResponseCode);
                } elseif ($this->httpResponseCode >= 500 && $this->httpResponseCode < 600) {
                    $ex = new Http500Exception('Error ' . $this->httpResponseCode, $this->httpResponseCode);
                } else {
                    $ex = new HttpResponseErrorException('Error ' . $this->httpResponseCode . ' message: ' . $this->httpResponseCode);
                }

                throw $ex;
        }

        return $this;
    }

    /**
     * Set request headers
     * Be very careful about the format
     * Keys are header name values are value!
     *
     * This is a good example of input:
     * $aHeaders = array(
     * 'Accept' => 'text/html,application/xhtml+xml,application/xml;',
     * 'Accept-Charset' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.3',
     * 'Accept-Language' => 'en-US,en;q=0.8',
     * 'Cache-Control' => 'no-cache'
     * );
     *
     * Setting Content-Length header is a VERY BAD idea, will
     * cause errors!
     * Curl will set the correct Content-Length header for you!
     * NEVER SET Content-Length manually!
     *
     * Setting Content-Type to application/x-www-form-urlencoded
     * in case of FORM POST is also a bad idea - curl
     * will do what has to be done for you in case of POST request
     * CURL USUALLY sets Content-Type to something like this:
     * multipart/form-data; boundary=----------------------------cdbe1229f9b4
     * SETTING It Manually is a BAD IDEA and oftern is source of errors
     *
     *
     * @param array $aHeaders
     */
    public function setHeaders(array $aHeaders) {
        if (count($aHeaders) > 0) {
            foreach ($aHeaders as $key => $value) {
                $this->aRequestHeaders[] = $key . ': ' . $value;
            }
        }

        return $this;
    }

    /**
     * Set curl options
     * This MUST be run before sending out a request,
     * usually it's run automatically from getDocument()
     *
     * @param array $aOptions
     *
     * @return object $this
     */
    protected function setOptions(array $aOptions = array()) {

        if (!empty($aOptions)) {
            $this->aOptions = array_merge($this->aOptions, $aOptions);
        }

        \curl_setopt($this->request, CURLOPT_HEADER, true);
        \curl_setopt($this->request, CURLOPT_RETURNTRANSFER, true);

        if (!empty($this->cookieFile)) {
            \curl_setopt($this->request, CURLOPT_COOKIEFILE, $this->cookieFile);
            \curl_setopt($this->request, CURLOPT_COOKIEJAR, $this->cookieFile);
        }

        if (!empty($this->aRequestHeaders)) {
            \curl_setopt($this->request, CURLOPT_HTTPHEADER, $this->aRequestHeaders);
        }

        if (!empty($this->referrer)) {
            \curl_setopt($this->request, CURLOPT_REFERER, $this->referrer);
        }

        if (!empty($this->aOptions['useragent'])) {
            \curl_setopt($this->request, CURLOPT_USERAGENT, $this->aOptions['useragent']);
        }

        if (!empty($this->aOptions['timeout'])) {
            \curl_setopt($this->request, CURLOPT_TIMEOUT, $this->aOptions['timeout']);
        }

        if (!empty($this->aOptions['redirect']) && $this->aOptions['redirect']) {
            \curl_setopt($this->request, CURLOPT_FOLLOWLOCATION, 5);
        }

        if (!empty($this->aOptions['login']) && !empty($this->aOptions['password'])) {
            \curl_setopt($this->request, CURLOPT_USERPWD, $this->aOptions['login'] . ':' . $this->aOptions['password']);
        }

        if (!empty($this->aOptions['method']) && 'POST' === strtoupper($this->aOptions['method'])) {
            curl_setopt($this->request, CURLOPT_POST, true);
        }

        if (!empty($this->aOptions['formVars'])) {
            $r1 = \curl_setopt($this->request, CURLOPT_POST, true);
            $r2 = \curl_setopt($this->request, CURLOPT_POSTFIELDS, $this->aOptions['formVars']);
            d('set CURLOPT_POSTFIELDS: ' . print_r($this->aOptions['formVars'], 1) . ' ret: ' . $r2);
        }

        if (!empty($this->aOptions['gzip'])) {
            \curl_setopt($this->request, CURLOPT_ENCODING, 'gzip');
        }

        if (!empty($this->aOptions['basicAuth'])) {
            \curl_setopt($this->request, CURLOPT_USERPWD, $this->aOptions['basicAuth']);
        }
        if (!empty($this->aOptions['ip'])) {
            \curl_setopt($this->request, CURLOPT_INTERFACE, $this->aOptions['ip']);
        }

        return $this;
    }

    /**
     * Set single curl option
     *
     * @param string $name
     * @param string $val
     */
    public function setOption($name, $val) {
        \curl_setopt($this->request, constant($name), $val);
    }

    /**
     * Destructor method to close open curl connection
     * and free up resource
     */
    public function __destruct() {
        if ($this->request && is_resource($this->request)) {
            @curl_close($this->request);
        }
    }

    /**
     * Get value of charset
     * as extracted from the
     * Content-Type header
     * Some servers have this info for the text content
     * like html or xml or other type of text, but
     * not all servers.
     * So this value may not be available all the time,
     * in which case the return value will be null
     *
     * @return mixed value of charset or null if not
     * available.
     *
     */
    public function getCharset() {
        $contentType = $this->info['content_type'];
        if (!empty($contentType) && preg_match('/charset=([\S]+)/', $contentType, $matches)) {

            if (!empty($matches[1])) {
                return \trim($matches[1]);
            }

            return null;
        }

        return null;
    }

    /**
     * Getter for $this->info array
     *
     * @return array
     */
    public function getCurlInfo() {

        return $this->info;
    }

    /**
     * Getter for $this->body
     * @return string body of http response
     */
    public function getResponseBody() {

        return $this->body;
    }

    /**
     * Getter for $this->httpResponseCode
     *
     * @return int http response code
     */
    public function getHttpResponseCode() {

        return (int) $this->httpResponseCode;
    }

    /**
     * Get value of specific header
     * if this header does not exists, will return empty string
     *
     * @param string $sHeader
     */
    public function getHeader($sHeader) {
        $sHeader = strtolower($sHeader);

        return (array_key_exists($sHeader, $this->aResponseHeaders)) ? $this->aResponseHeaders[$sHeader] : '';
    }

    /**
     * Returns value of Last-Modified header
     * OR 'Date' header
     * OR convert unix timestamp to date('r')
     *
     * @return string in the RFC 2822 date format
     */
    public function getLastModified() {
        if (!empty($this->aResponseHeaders['last-modified'])) {

            return $this->aResponseHeaders['last-modified'];
        } elseif (!empty($this->aResponseHeaders['date'])) {

            return $this->aResponseHeaders['date'];
        }

        return date('r');
    }

    /**
     * @return string value of 'Etag' header or empty string
     * if Etag is not present
     */
    public function getEtag() {

        return $this->getHeader('Etag');
    }

    public function getResponseHeaders() {

        return $this->aResponseHeaders;
    }

}
